Skip to content

Add streaming output via editMessageText + /verbose tool display#21

Open
1872-svg wants to merge 1 commit intomoazbuilds:masterfrom
1872-svg:feature/streaming
Open

Add streaming output via editMessageText + /verbose tool display#21
1872-svg wants to merge 1 commit intomoazbuilds:masterfrom
1872-svg:feature/streaming

Conversation

@1872-svg
Copy link
Copy Markdown

@1872-svg 1872-svg commented Mar 11, 2026

Summary

  • Live streaming responses: Claude's output streams to Telegram in real time using editMessageText. Sends a placeholder on the first chunk, then progressively edits it as text arrives (throttled to 500ms). Final edit applies full HTML formatting.
  • Race condition fix: waitForStreamMsg() ensures the placeholder sendMessage resolves before the handler decides to edit-vs-send, eliminating duplicate messages when Claude responds faster than the API round-trip.
  • /verbose mode: Toggle per-chat display of tool calls. When on, the streaming message shows tool activity above the response text:
    ● Write(tcp_tls.py)
      ⎿  [Write] Wrote 117 lines
    ● Bash(python3 test.py)
      ⎿  [Bash] all tests passed
    
    Final response text here...
    
  • /verbose is off by default — normal users see clean responses, power users can opt in.

Implementation details

runner.ts: switched main execution from --output-format json to --output-format stream-json --verbose. Added runClaudeStreaming() which parses NDJSON line-by-line, delivers text deltas via onChunk, and fires onToolEvent(line) for tool_use/tool_result events.

telegram.ts: makeStreamCallback() builds a closure that manages the streaming message lifecycle. In verbose mode it maintains separate toolLines and textAcc buffers and rebuilds the display on each flush.

Note: This PR depends on the fork-agent PR (#20) — it uses isMainBusy() and killActive() for auto-routing.

Test plan

  • Send any message — response should stream live instead of appearing all at once
  • /verbose → send a prompt that uses tools — tool calls should appear in the message as they happen
  • /verbose again — should toggle off, responses go back to clean text
  • Send a very fast one-word prompt — verify no duplicate messages

@1872-svg 1872-svg force-pushed the feature/streaming branch from 262862d to b60ba09 Compare March 11, 2026 03:37
- Stream-json output with real-time Telegram message editing
- /verbose command to show tool calls in streaming message
- Sliding window display (last 8 tool lines, 15 text lines) to stay within 4096 limit
- Newline separation between successive assistant message turns
- Fork agent (haiku) for parallel queries when main agent is busy
- /kill command to stop active agent
- Fix duplicate messages from edit/sendMessage race condition
- Fix verbose final message lost when edit fails silently
- Fix verbose fallback duplicate on short text-only responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@1872-svg 1872-svg force-pushed the feature/streaming branch from 3139412 to 30b4243 Compare March 11, 2026 12:41
Fenrur added a commit to Fenrur/claudeclaw that referenced this pull request Mar 13, 2026
… and /kill command

From upstream PR moazbuilds#21 by 1872-svg (streaming + verbose) and PR moazbuilds#20 (fork agent).
- Streaming output via editMessageText for real-time response display
- /verbose command to show tool calls in Telegram
- /fork command for parallel lightweight agent using Haiku
- /kill command to terminate active agent
- Auto-routing to fork when main agent is busy
- Merged with additionalDirs (moazbuilds#13) and session corruption fix (moazbuilds#26)

Co-Authored-By: 1872-svg <1872-svg@users.noreply.github.com>
justmaker added a commit to justmaker/claudeclaw that referenced this pull request Mar 31, 2026
- 重新組織為 Overview / Getting Started / Configuration / Features / Troubleshooting / Testing / Development
- 加入完整 settings.json schema 範例(含所有欄位與註解)
- 每個 feature 獨立章節:一句話說明 + 設定範例 + 行為說明
- 涵蓋所有已實作功能:Multi-Token Pool、OAuth、STT、Auto-Compact、Heartbeat、
  Thread Hire/Fire、Session Metrics、Concurrent Processing、Skill System、
  Graceful Shutdown、Structured Logging、Settings Hot-Reload
- 移除重複內容,統一格式
- 新增 Troubleshooting 常見問題表
- 新增 Development 專案結構說明
Copy link
Copy Markdown
Collaborator

@TerrysPOV TerrysPOV left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vision: PARTIAL — the streaming output feature is aligned and useful; the bundled fork/auto-routing re-introduces the silent implicit behavior that blocked PR #20.

Overlapping PRs: #82 (runner.ts + telegram.ts — session isolation; orthogonal concern but significant merge conflict risk on both files), #102 (runner.ts spawn env stripping — conflicts with new runClaudeStreaming spawn site)

Code review

Found 5 issues:

  1. Fork/kill/auto-routing re-introduced verbatim from closed PR #20 — the PR description says this depends on #20, which was closed with three specific requirements: (a) make routing opt-in via settings.json, (b) notify the user when routing to fork, (c) make FORK_MODEL configurable. None of these are addressed here.

claudeclaw/src/runner.ts

Lines 519 to 555 in 30b4243

"To peek at main agent progress: read session.json for the session ID, then read the .jsonl file in the transcripts dir.",
"Each JSONL line is a turn. The last few lines show what the main agent is currently doing.",
].join("\n");
const FORK_MODEL = "claude-haiku-4-5-20251001";
/** Run a fork agent — parallel, does NOT touch the main serial queue or main session. */
export async function runFork(prompt: string): Promise<RunResult> {
const { api } = getSettings();
const args = [
"claude", "-p", prompt,
"--output-format", "json",
"--dangerously-skip-permissions",
"--model", FORK_MODEL,
"--append-system-prompt", FORK_SYSTEM_PROMPT,
];
const { CLAUDECODE: _, ...cleanEnv } = process.env;
const baseEnv = { ...cleanEnv } as Record<string, string>;
const exec = await runClaudeOnce(args, FORK_MODEL, api, baseEnv);
let stdout = exec.rawStdout;
if (exec.exitCode === 0) {
try {
const json = JSON.parse(exec.rawStdout);
stdout = json.result ?? exec.rawStdout;
} catch {}
}
return { stdout, stderr: exec.stderr, exitCode: exec.exitCode };
}
/**
* Bootstrap the session: fires Claude with the system prompt so the
* session is created immediately. No-op if a session already exists.

  1. Auto-routing is still always-on with no opt-in and no user notification — when isMainBusy() is true, the message silently routes to a Haiku fork with a different system prompt and no main-session context, with no indication to the user.

const prefixedPrompt = promptParts.join("\n");
const busy = isMainBusy();
const verbose = verboseChats.has(chatId);
let result;
let streamMsgId: number | null = null;
let hadToolLines = false;
if (busy) {
result = await runFork(prefixedPrompt);
} else {
const stream = makeStreamCallback(config.token, chatId, threadId, { verbose });
result = await runUserMessage("telegram", prefixedPrompt, stream.onChunk, stream.onToolEvent);
const streamResult = await stream.waitForStreamMsg();
streamMsgId = streamResult.msgId;

  1. execClaude rewrite silently drops merged features — the replacement removes agentic model routing (selectModel(), merged in PR #17), sessionTimeoutMs timeout handling, auto-compact on exit code 124, and thread session key routing. These were deliberately added across multiple merged PRs and are gone with no discussion.

claudeclaw/src/runner.ts

Lines 377 to 475 in 30b4243

return "";
}
async function execClaude(name: string, prompt: string, onChunk?: (text: string) => void, onToolEvent?: (line: string) => void): Promise<RunResult> {
mainRunning = true;
try {
await mkdir(LOGS_DIR, { recursive: true });
const existing = await getSession();
const isNew = !existing;
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`);
const { security, model, api, fallback } = getSettings();
const primaryConfig: ModelConfig = { model, api };
const fallbackConfig: ModelConfig = {
model: fallback?.model ?? "",
api: fallback?.api ?? "",
};
const securityArgs = buildSecurityArgs(security);
console.log(
`[${new Date().toLocaleTimeString()}] Running: ${name} (${isNew ? "new session" : `resume ${existing.sessionId.slice(0, 8)}`}, security: ${security.level})`
);
// Always use stream-json — session_id comes from the result event for both new and resumed
// --verbose is required by claude when using stream-json with --print
const args = ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose", ...securityArgs];
if (!isNew) args.push("--resume", existing.sessionId);
// Build the appended system prompt (re-sent every turn since --append-system-prompt doesn't persist)
const promptContent = await loadPrompts();
const appendParts: string[] = ["You are running inside ClaudeClaw."];
if (promptContent) appendParts.push(promptContent);
if (existsSync(PROJECT_CLAUDE_MD)) {
try {
const claudeMd = await Bun.file(PROJECT_CLAUDE_MD).text();
if (claudeMd.trim()) appendParts.push(claudeMd.trim());
} catch (e) {
console.error(`[${new Date().toLocaleTimeString()}] Failed to read project CLAUDE.md:`, e);
}
}
if (security.level !== "unrestricted") appendParts.push(DIR_SCOPE_PROMPT);
if (appendParts.length > 0) args.push("--append-system-prompt", appendParts.join("\n\n"));
const { CLAUDECODE: _, ...cleanEnv } = process.env;
const baseEnv = { ...cleanEnv } as Record<string, string>;
let exec = await runClaudeStreaming(args, primaryConfig.model, primaryConfig.api, baseEnv, onChunk, onToolEvent);
let usedFallback = false;
if (exec.isRateLimit && hasModelConfig(fallbackConfig) && !sameModelConfig(primaryConfig, fallbackConfig)) {
console.warn(
`[${new Date().toLocaleTimeString()}] Claude limit reached; retrying with fallback${fallbackConfig.model ? ` (${fallbackConfig.model})` : ""}...`
);
exec = await runClaudeStreaming(args, fallbackConfig.model, fallbackConfig.api, baseEnv, onChunk, onToolEvent);
usedFallback = true;
}
const { result: stdout, stderr, exitCode, sessionId: streamedSessionId } = exec;
let sessionId = streamedSessionId ?? existing?.sessionId ?? "unknown";
// Persist session ID — works for both new and resumed (stream-json always emits it)
if (streamedSessionId && (isNew || streamedSessionId !== existing?.sessionId)) {
await createSession(streamedSessionId);
if (isNew) console.log(`[${new Date().toLocaleTimeString()}] Session created: ${streamedSessionId}`);
}
const result: RunResult = { stdout, stderr, exitCode };
const output = [
`# ${name}`,
`Date: ${new Date().toISOString()}`,
`Session: ${sessionId} (${isNew ? "new" : "resumed"})`,
`Model config: ${usedFallback ? "fallback" : "primary"}`,
`Prompt: ${prompt}`,
`Exit code: ${exitCode}`,
"",
"## Output",
stdout,
...(stderr ? ["## Stderr", stderr] : []),
].join("\n");
await Bun.write(logFile, output);
console.log(`[${new Date().toLocaleTimeString()}] Done: ${name}${logFile}`);
return result;
} finally {
mainRunning = false;
}
}
export async function run(name: string, prompt: string, onChunk?: (text: string) => void, onToolEvent?: (line: string) => void): Promise<RunResult> {
return enqueue(() => execClaude(name, prompt, onChunk, onToolEvent));
}
function prefixUserMessageWithClock(prompt: string): string {

  1. activeProc overwritten by concurrent fork — /kill targets wrong process — runClaudeOnce (used by runFork) and runClaudeStreaming (main path) both write to the same module-level activeProc. A concurrent fork overwrites the main agent's process reference, making /kill kill the fork instead, and leaving the main agent untrackable.

claudeclaw/src/runner.ts

Lines 161 to 165 in 30b4243

onChunk?: (text: string) => void,
onToolEvent?: (line: string) => void
): Promise<{ result: string; stderr: string; exitCode: number; sessionId?: string; isRateLimit: boolean }> {
const args = [...baseArgs];
const normalizedModel = model.trim().toLowerCase();

  1. Rate-limit fallback causes garbled streaming output — when the primary run hits a rate limit, runClaudeStreaming is retried with the same onChunk/onToolEvent callbacks. The Telegram stream message already has partial content from the first attempt; the fallback appends to the same accumulator, producing doubled/garbled output.

claudeclaw/src/runner.ts

Lines 408 to 416 in 30b4243

const promptContent = await loadPrompts();
const appendParts: string[] = ["You are running inside ClaudeClaw."];
if (promptContent) appendParts.push(promptContent);
if (existsSync(PROJECT_CLAUDE_MD)) {
try {
const claudeMd = await Bun.file(PROJECT_CLAUDE_MD).text();
if (claudeMd.trim()) appendParts.push(claudeMd.trim());
} catch (e) {


The streaming output feature (makeStreamCallback, runClaudeStreaming, /verbose) is well-implemented and worth having — it doesn't overlap with #82 (which covers session isolation, an orthogonal concern).
A split would unblock it: a streaming-only PR that (a) doesn't include the fork/kill machinery, (b) preserves the agentic routing and timeout safeguards from master, rather than replacing execClaude wholesale, and (c) handles the rate-limit fallback callback reset.

Happy to review that version.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants